前兩天我們先處理了兩件事情。
Day 1 講的是:
為什麼政府公文不能只當成一般 OCR 問題。
Day 2 則先建立了一把尺:
到底怎樣才算 OCR 準。
今天終於可以正式把整套系統攤開來看。
如果用一句話形容我最初對 OCR 的想像,大概就是:
圖片 → OCR Model → 文字
但實際做到最後,我的架構完全不是這樣。
比較接近:
PDF / Image
↓
文字偵測
↓
版面分析
↓
表格結構分析
↓
文字辨識
↓
後處理
↓
文字 + 座標 + 表格結構
也就是說:
真正「讀字」的模型,其實到了很後面才出場。
現在的 Vision Language Model 已經很強了。
拿一張文件圖片丟進去,再問:
「請把這張圖片的文字完整轉出來。」
確實常常可以得到看起來相當不錯的結果。
所以一開始很自然會問:
既然 VLM 已經看得懂圖片,為什麼還要弄這麼多層?
答案其實跟 Day 1 講的問題有關。
我真正需要的不只是:
「大概知道這頁寫什麼。」
而是希望最後可以拿到:
完整文字
每段文字的位置
正確閱讀順序
表格 cell
橫排與直排內容
關鍵欄位
如果只是拿去做圖片問答,整頁 VLM 很合理。
但如果後面還要進:
RAG、文件檢索、AI 審查、欄位擷取或結構化資料庫
那我就不能接受:
「意思差不多。」
我需要的是:
這個字在哪裡、屬於哪一段、哪一格,以及到底寫了什麼。
所以最後我把問題拆成四層。
第一個出場的是:
CRAFT。
它在我的系統裡不是負責辨識文字。
它只負責一件事情:
找出文字的位置。
例如一張 200 dpi 的公文影像進來。
CRAFT 不需要知道上面寫的是:
「環境部」
還是:
「行政院」。
它只要告訴我:
這裡有一行字
這裡也有一行字
下面還有一塊文字
然後回傳一個個 Bounding Box。
概念上就像:
┌──────────────────────────┐
│ ○○○○○○○ │
│ │
│ ┌───────────────────┐ │
│ │ 這裡是一段正文 │ │
│ └───────────────────┘ │
│ │
│ ┌─────────┐ ┌────────┐ │
│ │ 表格文字 │ │ 表格文字│ │
│ └─────────┘ └────────┘ │
└──────────────────────────┘
CRAFT 做的就是把這些文字區域找出來。
這個階段很重要。
因為後面的辨識模型並不是每次直接看整張 A4。
而是:
先找到文字,再把小區域裁下來。
這種裁下來的小圖,我後面都會叫它:
crop。
例如一頁公文最後可能被切成數十甚至數百個 crop。
辨識模型真正看到的是這些小塊圖片。
原因很簡單。
假設一張公文有:
30 行正文、10 個表格欄位、3 個標題。
如果直接把整張 200 dpi 文件縮進模型固定的輸入尺寸,
原本很清楚的一個中文字,很可能只剩下非常少的 pixel。
尤其如果是一整條很長的橫排文字:
本案依政府採購法第○○條規定辦理後續相關事宜......
壓縮之後,每一個字都會變得非常小。
但如果我先把這一行裁下來:
┌────────────────────────────────────┐
│ 本案依政府採購法第○○條規定辦理... │
└────────────────────────────────────┘
再送進辨識模型,
模型就能把大部分解析度用在真正需要辨識的文字上。
所以第一層解決的是:
Where?字在哪裡?
還沒有開始回答:
What?它到底寫了什麼?
CRAFT 找出文字之後,新的問題馬上出現。
假設我現在拿到 100 個文字框。
那它們是什麼?
有些可能是:
正文。
有些是:
表格。
有些是:
頁眉。
有些是:
頁尾。
旁邊甚至可能還有圖片。
所以第二層我用了:
docling-layout-heron
來做版面分析。
它的工作可以理解成:
判斷頁面上的區域屬於什麼類型。
例如:
┌──────────────────────────────┐
│ HEADER │
├──────────────────────────────┤
│ │
│ TEXT │
│ │
├──────────────┬───────────────┤
│ │ │
│ TABLE │ PICTURE │
│ │ │
├──────────────┴───────────────┤
│ FOOTER │
└──────────────────────────────┘
這一步不是為了認字。
而是為了決定:
這個區域後面該走哪條路。
如果 Heron 判斷這是一塊:
一般正文
那就可以直接準備送去文字辨識。
但如果判斷是:
表格
事情就不能這麼簡單。
因為表格最重要的並不只是「裡面有哪些字」。
還有:
這些字屬於哪一格。
所以表格會進入第三層。
假設我們有這樣一張表:
項目 數量 金額
電腦 10 300,000
螢幕 20 160,000
如果 OCR 最後只吐出:
項目
數量
金額
電腦
10
300000
螢幕
20
160000
雖然每個字可能全部認對,
但對程式來說:
結構其實已經不見了。
它不知道:
300000
到底是電腦的金額,
還是螢幕的金額。
所以表格區域會另外進行:
格線偵測 → 網格建構 → cell 還原
包括跨欄的儲存格也需要考慮。
最後的目標不是:
「這裡有九段文字。」
而是:
row 1
col 1 = 項目
col 2 = 數量
col 3 = 金額
row 2
col 1 = 電腦
col 2 = 10
col 3 = 300000
這就是為什麼:
OCR 與 Table Recognition 其實不能完全當成同一件事。
到目前為止:
CRAFT 找到了文字。
Heron 知道它屬於正文、表格還是其他區域。
表格也已經把 cell 建好。
現在終於可以開始問:
「這張小圖到底寫了什麼?」
這一層我使用的是:
PaliGemma2-3B
搭配 LoRA。
但有趣的是:
我最後也不是只有一組 LoRA。
而是三組:
PaliGemma2-3B Base
│
├── 橫排 LoRA
│
├── 直排 LoRA
│
└── 關鍵欄位 LoRA
三組 LoRA 共用同一份 Base Model。
這件事情後面還會非常重要。
因為一開始如果每組 LoRA 都各自載一份 Base,
GPU 記憶體會直接被吃掉非常大一塊。
後面做到單卡最佳化的時候,
我就是在這裡找到了一個很大的記憶體浪費來源。
但這個坑我們留到後面再談。
這其實是政府文件很常見的一個問題。
例如正文:
本案經本會審查通過
很明顯是橫排。
但表格裡可能出現:
預
算
科
目
同一套文件裡兩種方向同時存在。
因此在 crop 準備送進 PaliGemma2 之前,
系統還要先判斷:
這塊是橫排還是直排?
再決定交給哪一組 LoRA。
另外還有一些我特別在意的欄位,
例如特定的重要資訊,
會有另外的關鍵欄位辨識策略。
所以實際辨識階段並不是:
crop
↓
OCR
而比較像:
┌→ 橫排 LoRA
crop → 分流 ├→ 直排 LoRA
└→ 關鍵欄位 LoRA
這裡有一個工程上的考量。
PaliGemma2-3B 本身是一個數十億參數的模型。
如果今天我要:
橫排一份模型。
直排一份模型。
關鍵欄位再一份模型。
那 GPU 就得放三份完整權重。
代價非常高。
所以比較合理的做法是:
一份 Base Model
+
三組很小的 LoRA Adapter
需要哪個任務,
就使用對應的 Adapter。
因此:
任務專門化,不代表一定要複製整個模型。
這也是後面我把常駐權重記憶體降下來的重要基礎。
到這裡看起來好像結束了。
其實還沒有。
模型輸出的結果還要經過一整串後處理。
例如:
重複消除。
有時候不同偵測框可能覆蓋到同一段文字。
如果全部保留:
環境部
環境部
最後文件就會出現重複內容。
另外還有:
前導點處理。
像目錄或預算文件裡常見:
第一章....................10
以及:
標點還原。
最後還有一件非常重要的事情:
閱讀順序。
因為模型實際上一塊一塊辨識。
最後必須重新知道:
哪一塊是第一段?
哪一塊接第二段?
表格應該插在哪裡?
否則就會出現 Day 2 講過的情況:
每一塊都認對,但整份文件讀起來是亂的。
如果簡化成一張圖:
PDF / Image
│
▼
200 dpi 頁面影像
│
▼
CRAFT
文字偵測
「字在哪裡?」
│
▼
Heron
版面分析
「這是正文、表格還是圖片?」
│
├───────────────┐
│ 正文 │ 表格
▼ ▼
文字框 格線偵測
網格建構
│
└──────────┬────┘
▼
crop 指派與切分
│
▼
判斷辨識策略
┌──────┼──────┐
▼ ▼ ▼
橫排 直排 關鍵欄位
LoRA LoRA LoRA
└──────┼──────┘
▼
PaliGemma2
文字辨識
│
▼
後處理
重複 → 標點 → 閱讀順序
│
▼
文字 + 座標 + 表格結構
看到這裡,應該就比較能理解:
為什麼我最後做的不是「一個 OCR 模型」。
而是一條 Document AI Pipeline。
我後來實際量測整套系統。
以目前使用的設定來看,
前面的:
文字偵測 + 版面分析 + 表格結構
加起來中位時間大約:
0.74 秒。
而真正的辨識階段大約:
4.15 秒。
也就是說:
整條 Pipeline 裡,
真正最重的部分依然是:
PaliGemma2 的文字生成。
這件事情後來直接影響了我很多最佳化方向。
例如:
如果我要提升整體吞吐量,
花很多時間把一個 0.1 秒的步驟再砍一半,
實際上幫助很有限。
反而應該優先處理:
辨識模型、GPU 記憶體與 Concurrent。
這也是後面單卡最佳化會一直圍繞 PaliGemma2 的原因。
雖然它不是最慢的部分,
但 CRAFT 曾經是:
GPU 記憶體的大戶。
而記憶體在這個專案裡非常重要。
因為我不是只想做到:
「一張文件可以成功跑完。」
我要的是:
同一張 24GB GPU 能不能讓多個使用者一起用?
只要前面的 CRAFT 多吃幾 GB,
後面就可能少一個 PaliGemma2 的並行名額。
所以後來我做了一件很有趣的實驗:
把 CRAFT 的 Canvas 從比較大的設定一路往下調,
觀察:
到底可以少吃多少 VRAM,又不會開始漏字?
結果峰值記憶體最後降了非常多。
這部分後面會單獨寫一篇。
今天最重要的其實不是記住:
CRAFT、Heron、PaliGemma2。
而是理解這個拆法:
找得到、分得對、讀得準,是三個不同問題。
CRAFT 解決:
字在哪裡?
Heron 解決:
這是什麼區域?
表格層解決:
文字彼此是什麼結構?
PaliGemma2 解決:
這張小圖到底寫了什麼?
後處理再負責:
怎麼把所有結果重新組成一份可以使用的文件。
也因為我把問題拆開,
後面才有辦法分別去量:
哪一層不準?
哪一層太慢?
哪一層太吃 GPU?
而不是看到最後 OCR 錯了,只知道一句:
「模型好像不夠好。」
下一篇 Day 4,我想再往下問一個很自然的問題:
我會實際從解析度、長寬比與文字尺寸去看:
當一整頁公文被塞進模型固定的輸入尺寸後,到底發生了什麼事。
也會開始碰到後面非常關鍵的一個問題:
同一個模型,為什麼你怎麼切圖片,可能比你換模型還重要?